Skip to content

fix(install): recover from non-directory entries blocking .bit_roots links - #10355

Merged
zkochan merged 8 commits into
teambit:masterfrom
zkochan:fix/hard-link-recover-from-non-dir
Sep 4, 2026
Merged

fix(install): recover from non-directory entries blocking .bit_roots links#10355
zkochan merged 8 commits into
teambit:masterfrom
zkochan:fix/hard-link-recover-from-non-dir

Conversation

@zkochan

@zkochan zkochan commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

hardLinkDirectory is invoked during post-install linking into node_modules/.bit_roots/<env>/.... If a previous install was interrupted or the env layout drifted across versions, an ancestor directory in the target path can be left behind as a regular file or a dangling symlink. That makes fs.mkdir(..., { recursive: true }) throw ENOTDIR (or ENOENT through a broken symlink) and aborts the whole install with no clear remediation other than deleting .bit_roots.

A user hit this on bit install:

✔ done running package installation using pnpm (completed in 5s)
✔ running post install subscribers
ENOTDIR: not a directory, mkdir '/home/user/hope-mobile/node_modules/.bit_roots/bitdev.react-native_react-native-env@2.0.0/node_modules/@teambit/hope.hope-mobile'

This PR makes the linker self-heal in that situation:

  • A new ensureDir helper handles ENOTDIR, EEXIST, and ENOENT from fs.mkdir. It walks up the path with lstat, finds the deepest existing ancestor that is not a directory, and moves it aside rather than deleting it.
  • Quarantine reserves a unique sibling directory such as <offender>.bit-stray-<timestamp> and atomically renames the blocker into <quarantine-dir>/<basename>. The reserved directory prevents destination clobbering; the single rename prevents replacement races and preserves files, symlinks, and Windows junctions without recreating them.
  • Concurrent workers are handled safely: one rename wins, and workers seeing ENOENT retry directory creation. Timestamp collisions are suffix-bumped without overwriting an earlier quarantine.
  • The warning is recorded with logger.warn and displayed through the shared formatWarningSummary CLI formatter, while honoring no_warnings and disabled-console modes. It includes both the original and quarantine paths for manual inspection.

The change also replaces catch (err: any) with catch (err) and a small errnoCode(err: unknown) helper.

Test plan

  • Focused Mocha suite: 10/10 passing
    • ancestor exists as a regular file
    • exact target subdirectory exists as a regular file
    • dangling directory junction/symlink is preserved at quarantine
    • an existing quarantine path is not overwritten
    • concurrent recovery of the same blocker succeeds without losing data
    • existing hard-link, symlink, broken-link, and cross-device fallback cases remain green
  • Prettier check on both changed files
  • Oxlint on both changed files
  • git diff --check

@zkochan
zkochan marked this pull request as ready for review May 8, 2026 13:28
Copilot AI review requested due to automatic review settings May 8, 2026 13:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens hardLinkDirectory() against corrupted destination paths (e.g., where an expected directory in the destination tree is instead a regular file or a dangling symlink), by quarantining the blocking entry and retrying directory creation so installs/linking can proceed without manual cleanup.

Changes:

  • Introduce ensureDir() + helpers to recover from mkdir(..., { recursive: true }) failures caused by non-directory path entries, by renaming the blocking entry aside and retrying.
  • Update hardLinkDirectory() / linkFile() to route directory creation through ensureDir() and normalize errno handling via errnoCode().
  • Add unit tests covering recovery when destination ancestors/targets are regular files or dangling symlinks.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Adds self-healing directory creation (ensureDir) and wires it into linking flow; adds warnings via legacy logger.
scopes/toolbox/fs/hard-link-directory/hard-link-directory.spec.ts Adds regression tests for recovery/quarantine behavior under corrupted destination layouts.

Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated
Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated
zkochan added a commit that referenced this pull request May 12, 2026
…abled (#10356)

## Summary

The post-install path runs through `linkCodemods` →
`linkToNodeModulesByIds` → `NodeModuleLinker.link()`, and that ended
with an **unconditional** call to `linkPkgsToRootComponents` at
`node-modules-linker.ts:73`. The sibling call site in
`install.main.runtime.ts` (`_linkAllComponentsToBitRoots`, line 1272) is
already gated on `dependencyResolver.hasRootComponents()` — this aligns
the linker with that.

### When this matters

A user toggles `rootComponents` from `true` to `false` and runs `bit
install`:

1. Prior install (with `rootComponents: true`) populated
`node_modules/.bit_roots/<env>/node_modules/...`.
2. New install (with `rootComponents: false`): pnpm no longer treats
`.bit_roots/<env>` as a workspace project, and `_updateRootDirs` is
skipped, so that subtree is no longer managed by anyone. Its internal
layout can drift — e.g. ancestors of `<env>/node_modules/<pkg>` may have
been reshaped, leaving a regular file or a broken link where a directory
used to be.
3. `NodeModuleLinker.link()` then tries to hard-link the workspace's
`node_modules/<pkg>` into that stale tree, and `mkdir(... { recursive:
true })` throws `ENOTDIR` (or `ENOENT` through a broken symlink). The
whole install aborts.

Concrete report from a user:
```
✔ done running package installation using pnpm (completed in 5s)
✔ running post install subscribers
ENOTDIR: not a directory, mkdir '/home/user/hope-mobile/node_modules/.bit_roots/bitdev.react-native_react-native-env@2.0.0/node_modules/@teambit/hope.hope-mobile'
```

### Changes

- `Workspace.hasRootComponents()` — small public method that delegates
to the private `dependencyResolver.hasRootComponents()`. The linker (and
any other consumer of `Workspace`) can now check this without reaching
into the private resolver.
- `NodeModuleLinker.link()` — gates the `linkPkgsToRootComponents` call
on `workspace.hasRootComponents()`. When root components are off, the
stale `.bit_roots` tree is left strictly alone.

### Notes

- The defensive recovery in #10355 turns the underlying ENOTDIR into a
recoverable warning when it does happen. This PR is the upstream fix
that prevents bit from touching `.bit_roots` in the first place when it
shouldn't. The two are complementary — happy to land in either order.
- This PR does not clean up an existing `.bit_roots` when the user
toggles to `false`. The stale tree is wasted disk but no longer actively
harmful once we stop writing to it. If you want bit to also remove it on
toggle, that's a separate change worth thinking through (someone may
have tooling that reads from there independently).

## Test plan

- [x] `npm run lint` — same 38 pre-existing TS errors as master (all in
unrelated `@pnpm/*` imports), no new errors in the changed files
- [ ] e2e test — the existing `e2e/harmony/root-components.e2e.ts` is
the natural home for a "toggle off and reinstall" scenario but I held
off on adding one in this draft; let me know if you want it bundled in.
@zkochan
zkochan force-pushed the fix/hard-link-recover-from-non-dir branch from b415bd7 to c57d3c5 Compare September 4, 2026 13:29
@zkochan
zkochan enabled auto-merge (squash) September 4, 2026 13:30
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Recovery moves arbitrary path entries 🐞 Bug ⛨ Security
Description
ensureDir quarantines blockers without checking that they belong to a generated Bit directory,
while injected destinations can be arbitrary absolute paths read from package-manager metadata. A
stale or malformed injected location can therefore cause an unrelated file or symlink outside the
workspace to be moved during compilation.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[137]

+    const quarantined = await quarantineStrayEntry(offender);
Evidence
The compiler passes absolute injected locations unchanged to hardLinkDirectory, and Yarn obtains
those locations directly from .yarn-state.yml without validating containment. The added recovery
walks those paths and renames the first non-directory ancestor it encounters.

scopes/compilation/compiler/compiler.task.ts[64-83]
scopes/dependencies/yarn/yarn.package-manager.ts[492-510]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[115-145]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[192-207]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new recovery path can rename a blocking entry at any absolute destination supplied to `hardLinkDirectory`. Package-manager metadata can supply absolute injected locations without containment validation, allowing unrelated filesystem entries to be moved.
## Issue Context
The compiler preserves absolute injected paths, and Yarn forwards locations from `.yarn-state.yml`. Recovery should only mutate generated directories explicitly owned by Bit.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[115-145]
- scopes/compilation/compiler/compiler.task.ts[64-83]
- scopes/dependencies/yarn/yarn.package-manager.ts[492-510]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Junction quarantine fails on Windows 🐞 Bug ☼ Reliability
Description
moveNonDirectoryEntryNoReplace recreates every symbolic link without preserving its link type, so
a dangling Windows directory junction is recreated as a file symlink that may require unavailable
symlink privileges. Recovery then throws instead of repairing the destination, despite junctions
being the repository's supported Windows directory-link mechanism.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[R175-177]

+  if (stat.isSymbolicLink()) {
+    const target = await fs.readlink(src);
+    await fs.symlink(target, dest);
Evidence
The quarantine branch treats all symbolic links identically and omits the symlink type. The
repository's existing link utility explicitly uses junction for directories because that form
works on both Linux and Windows, demonstrating that the omitted type is operationally significant.

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[173-180]
scopes/toolbox/fs/link-or-symlink/create-link-or-symlink.ts[108-127]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Quarantining a dangling directory junction recreates it using `fs.symlink(target, dest)` without a link type. On Windows this produces a file symlink rather than the repository's supported junction form and can fail when symlink privileges are unavailable.
## Issue Context
The recovery feature explicitly handles dangling symlinks. Existing filesystem utilities use the `junction` type for directory links because it works on Windows without ordinary symlink permissions.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[173-180]
- scopes/toolbox/fs/link-or-symlink/create-link-or-symlink.ts[108-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. printWarning bypasses shared formatter ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new recovery path emits CLI warning output through legacy printWarning instead of the shared
@teambit/cli warning formatter. This makes the modified output inconsistent with the mandated CLI
formatting path.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[141]

+    printWarning(msg);
Evidence
PR Compliance ID 1 requires modified CLI output to use the shared formatting toolkit. The changed
code imports legacy printWarning and invokes it for the new user-visible warning, while the
repository's shared formatter provides formatWarningSummary for warning output.

CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide: CLAUDE.md: Use Shared CLI Output Formatting and Follow the CLI Style Guide
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[5-5]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[137-141]
scopes/harmony/cli/output-formatter.ts[64-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The recovery warning is printed with legacy `printWarning` rather than the shared CLI output formatting toolkit required for modified CLI output.
## Issue Context
Preserve debug logging and warning-suppression behavior while routing the user-visible warning through `formatWarningSummary` or an equivalent shared `@teambit/cli` formatting path.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[5-5]
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[137-141]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Quarantine retargets relative symlinks 🐞 Bug ≡ Correctness
Description
Quarantine moves an offending symlink from its original parent into a nested directory, so any
relative link target is subsequently resolved from a different directory. The entry is retained, but
it no longer references the same path and cannot be restored later with its original behavior.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[R169-172]

+    const quarantined = path.join(quarantineDir, path.basename(offender));
+    try {
+      await fs.rename(offender, quarantined);
+      return quarantined;
Evidence
The quarantine destination is one directory deeper than the offender's original location: it is
constructed beneath a newly created sibling directory and the symlink is renamed there unchanged.
The added test verifies only an absolute target created with path.join(tempDir, ...), leaving
relative-target resolution uncovered.

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[157-172]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.spec.ts[190-213]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Moving a relative symlink into `<offender>.bit-stray-*/<basename>` changes the base directory used to resolve its target. Quarantining should preserve the symlink's effective target as well as its link text and type.
## Issue Context
The existing symlink test uses an absolute target, so it does not detect this behavior. Add coverage for a relative dangling symlink and a relative symlink targeting a non-directory entry.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[157-179]
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.spec.ts[190-214]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Runtime dependencies remain undeclared 🐞 Bug ☼ Reliability
Description
hard-link-directory now imports four Bit packages at runtime, but its lockfile importer still
declares only the existing filesystem dependencies. Isolated or filtered installations can therefore
omit these packages and fail while loading the module.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[R5-8]

+import { formatWarningSummary } from '@teambit/cli';
+import { getConfig } from '@teambit/config-store';
+import { CFG_NO_WARNINGS } from '@teambit/legacy.constants';
+import { logger } from '@teambit/legacy.logger';
Evidence
The component imports @teambit/cli, @teambit/config-store, @teambit/legacy.constants, and
@teambit/legacy.logger, while its lockfile importer contains none of them and lists only the
pre-existing filesystem dependencies.

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[5-8]
pnpm-lock.yaml[23891-23919]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new runtime imports are absent from the `hard-link-directory` component's dependency metadata in `pnpm-lock.yaml`, allowing isolated installations to omit required modules.
## Issue Context
The current lockfile importer lists `fs-extra`, `resolve-link-target`, and `symlink-dir`, but none of the four newly imported Bit packages.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[5-8]
- pnpm-lock.yaml[23891-23919]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Replacement race deletes new entry 🐞 Bug ☼ Reliability
Description
The quarantine operation separately links and then unlinks the offender pathname, so another process
can replace that pathname between those operations and have its new file unlinked. This can remove
concurrently created user or package-manager data while reporting the replacement as safely
quarantined.
Code

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[R179-183]

+    await fs.link(src, dest);
+  }
+
+  try {
+    await fs.unlink(src);
Evidence
The implementation obtains metadata, creates a hard link or replacement symlink, and only afterward
unconditionally unlinks src; there is no source identity validation or atomic move spanning those
operations. The caller treats completion as a successful quarantine and proceeds with directory
creation.

scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[153-165]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[173-189]
scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[127-142]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The current link-then-unlink sequence does not bind the final unlink to the inode that was linked. If another process replaces the offender pathname before `unlink`, the replacement is deleted.
## Issue Context
The code intentionally supports concurrent workers, but its current handling only covers workers removing the same unchanged offender. Use an atomic move strategy with a safely reserved non-colliding destination, or another mechanism that cannot unlink a replacement entry.
## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[153-189]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can route each action level your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated
Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated
Comment thread scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 406f48b

…links

`hardLinkDirectory` is invoked during post-install linking into
`node_modules/.bit_roots/<env>/...`. If a previous install was interrupted
or the env layout drifted across versions, an ancestor directory in the
target path can be left behind as a regular file or a dangling symlink.
That made `mkdir(... { recursive: true })` throw `ENOTDIR` (or `ENOENT`
through a broken symlink) and aborted the whole install with no clear
remediation other than `rm -rf node_modules/.bit_roots`.

Detect this case, remove the offending non-directory entry, retry the
mkdir, and surface a warning. The destination tree under `.bit_roots` is
owned by bit and rebuilt on every install, so deleting a stray entry is
safe.
Use printWarning + logger.warn from @teambit/legacy.logger as the default
onWarn for hardLinkDirectory, so the recovery message both surfaces in
the CLI (yellow "Warning: …", honoring no_warnings config) and lands in
debug.log alongside the install context. Tests still inject their own
collector, so this stays unit-testable without touching the global logger.
Always go through bit's logger (logger.warn + printWarning). The option
existed only to keep tests from depending on the global logger, but the
recovery contract is sufficiently verified by asserting the file gets
linked through — the warning is a side effect, not the behavior under
test.
Centralize the cast to NodeJS.ErrnoException in a tiny helper so each
catch site stays an unknown without sprinkling `as any` around.
The blocking entry could be high up the path (a stray file at @scope, or
even at node_modules itself in a weird state) and we don't want to discard
the user's data on a heuristic. Rename it to <offender>.bit-stray-<ts>
alongside, surface that path in the warning, and let the user inspect or
remove it themselves. The retry mkdir then succeeds because the original
name is free.
@zkochan
zkochan force-pushed the fix/hard-link-recover-from-non-dir branch from 406f48b to e23ed75 Compare September 4, 2026 15:06
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e23ed75

// Another worker may have already moved the offender. Retry mkdir against the new state.
continue;
}
const quarantined = await quarantineStrayEntry(offender);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Recovery moves arbitrary path entries 🐞 Bug ⛨ Security

ensureDir quarantines blockers without checking that they belong to a generated Bit directory,
while injected destinations can be arbitrary absolute paths read from package-manager metadata. A
stale or malformed injected location can therefore cause an unrelated file or symlink outside the
workspace to be moved during compilation.
Agent Prompt
## Issue description
The new recovery path can rename a blocking entry at any absolute destination supplied to `hardLinkDirectory`. Package-manager metadata can supply absolute injected locations without containment validation, allowing unrelated filesystem entries to be moved.

## Issue Context
The compiler preserves absolute injected paths, and Yarn forwards locations from `.yarn-state.yml`. Recovery should only mutate generated directories explicitly owned by Bit.

## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[115-145]
- scopes/compilation/compiler/compiler.task.ts[64-83]
- scopes/dependencies/yarn/yarn.package-manager.ts[492-510]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +169 to +172
const quarantined = path.join(quarantineDir, path.basename(offender));
try {
await fs.rename(offender, quarantined);
return quarantined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Quarantine retargets relative symlinks 🐞 Bug ≡ Correctness

Quarantine moves an offending symlink from its original parent into a nested directory, so any
relative link target is subsequently resolved from a different directory. The entry is retained, but
it no longer references the same path and cannot be restored later with its original behavior.
Agent Prompt
## Issue description
Moving a relative symlink into `<offender>.bit-stray-*/<basename>` changes the base directory used to resolve its target. Quarantining should preserve the symlink's effective target as well as its link text and type.

## Issue Context
The existing symlink test uses an absolute target, so it does not detect this behavior. Add coverage for a relative dangling symlink and a relative symlink targeting a non-directory entry.

## Fix Focus Areas
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.ts[157-179]
- scopes/toolbox/fs/hard-link-directory/hard-link-directory.spec.ts[190-214]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ed8e22d

@zkochan
zkochan merged commit eb5bd91 into teambit:master Sep 4, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants